import { ASTUtils } from '@typescript-eslint/utils';

import {
	findClosestVariableDeclaratorNode,
	findImportSpecifier,
	getAssertNodeInfo,
	getDeepestIdentifierNode,
	getImportModuleName,
	getPropertyIdentifierNode,
	getReferenceNode,
	hasImportMatch,
	isCallExpression,
	isImportDeclaration,
	isImportDefaultSpecifier,
	isImportSpecifier,
	isLiteral,
	isMemberExpression,
} from '../node-utils';
import {
	ABSENCE_MATCHERS,
	ALL_QUERIES_COMBINATIONS,
	ASYNC_UTILS,
	DEBUG_UTILS,
	PRESENCE_MATCHERS,
	USER_EVENT_MODULE,
} from '../utils';
import {
	isCustomTestingLibraryModule,
	isOfficialTestingLibraryModule,
	isTestingLibraryModule,
} from '../utils/is-testing-library-module';

import type { ImportModuleNode } from '../node-utils';
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';

const SETTING_OPTION_OFF = 'off';

export type TestingLibrarySettings = {
	'testing-library/utils-module'?:
		| typeof SETTING_OPTION_OFF
		| (string & NonNullable<unknown>);
	'testing-library/custom-renders'?: string[] | typeof SETTING_OPTION_OFF;
	'testing-library/custom-queries'?: string[] | typeof SETTING_OPTION_OFF;
};

export type TestingLibraryContext<
	TMessageIds extends string,
	TOptions extends readonly unknown[],
> = Readonly<
	TSESLint.RuleContext<TMessageIds, TOptions> & {
		settings: TestingLibrarySettings;
	}
>;

export type EnhancedRuleCreate<
	TMessageIds extends string,
	TOptions extends readonly unknown[],
> = (
	context: TestingLibraryContext<TMessageIds, TOptions>,
	optionsWithDefault: Readonly<TOptions>,
	detectionHelpers: Readonly<DetectionHelpers>
) => TSESLint.RuleListener;

// Helpers methods
type GetTestingLibraryImportNodeFn = () => ImportModuleNode | null;
type GetTestingLibraryImportNodesFn = () => ImportModuleNode[];
type GetCustomModuleImportNodeFn = () => ImportModuleNode | null;
type GetTestingLibraryImportNameFn = () => string | undefined;
type GetCustomModuleImportNameFn = () => string | undefined;
type IsTestingLibraryImportedFn = (isStrict?: boolean) => boolean;
type IsGetQueryVariantFn = (node: TSESTree.Identifier) => boolean;
type IsQueryQueryVariantFn = (node: TSESTree.Identifier) => boolean;
type IsFindQueryVariantFn = (node: TSESTree.Identifier) => boolean;
type IsSyncQueryFn = (node: TSESTree.Identifier) => boolean;
type IsAsyncQueryFn = (node: TSESTree.Identifier) => boolean;
type IsQueryFn = (node: TSESTree.Identifier) => boolean;
type IsCustomQueryFn = (node: TSESTree.Identifier) => boolean;
type IsBuiltInQueryFn = (node: TSESTree.Identifier) => boolean;
type IsAsyncUtilFn = (
	node: TSESTree.Identifier,
	validNames?: readonly (typeof ASYNC_UTILS)[number][]
) => boolean;
type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean;
type IsUserEventMethodFn = (
	node: TSESTree.Identifier,
	userEventSetupVars?: Set<string>
) => boolean;
type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean;
type IsCreateEventUtil = (
	node: TSESTree.CallExpression | TSESTree.Identifier
) => boolean;
type IsRenderVariableDeclaratorFn = (
	node: TSESTree.VariableDeclarator
) => boolean;
type IsDebugUtilFn = (
	identifierNode: TSESTree.Identifier,
	validNames?: ReadonlyArray<(typeof DEBUG_UTILS)[number]>
) => boolean;
type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean;
type IsMatchingAssertFn = (
	node: TSESTree.MemberExpression,
	matcherName: string
) => boolean;
type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean;
type CanReportErrorsFn = () => boolean;
type FindImportedTestingLibraryUtilSpecifierFn = (
	specifierName: string
) => TSESTree.Identifier | TSESTree.ImportClause | undefined;
type IsNodeComingFromTestingLibraryFn = (
	node: TSESTree.Identifier | TSESTree.MemberExpression
) => boolean;

export interface DetectionHelpers {
	getTestingLibraryImportNode: GetTestingLibraryImportNodeFn;
	getAllTestingLibraryImportNodes: GetTestingLibraryImportNodesFn;
	getCustomModuleImportNode: GetCustomModuleImportNodeFn;
	getTestingLibraryImportName: GetTestingLibraryImportNameFn;
	getCustomModuleImportName: GetCustomModuleImportNameFn;
	isTestingLibraryImported: IsTestingLibraryImportedFn;
	isTestingLibraryUtil: (node: TSESTree.Identifier) => boolean;
	isGetQueryVariant: IsGetQueryVariantFn;
	isQueryQueryVariant: IsQueryQueryVariantFn;
	isFindQueryVariant: IsFindQueryVariantFn;
	isSyncQuery: IsSyncQueryFn;
	isAsyncQuery: IsAsyncQueryFn;
	isQuery: IsQueryFn;
	isCustomQuery: IsCustomQueryFn;
	isBuiltInQuery: IsBuiltInQueryFn;
	isAsyncUtil: IsAsyncUtilFn;
	isFireEventUtil: (node: TSESTree.Identifier) => boolean;
	isUserEventUtil: (node: TSESTree.Identifier) => boolean;
	isFireEventMethod: IsFireEventMethodFn;
	isUserEventMethod: IsUserEventMethodFn;
	isRenderUtil: IsRenderUtilFn;
	isCreateEventUtil: IsCreateEventUtil;
	isRenderVariableDeclarator: IsRenderVariableDeclaratorFn;
	isDebugUtil: IsDebugUtilFn;
	isActUtil: (node: TSESTree.Identifier) => boolean;
	isPresenceAssert: IsPresenceAssertFn;
	isAbsenceAssert: IsAbsenceAssertFn;
	isMatchingAssert: IsMatchingAssertFn;
	canReportErrors: CanReportErrorsFn;
	findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn;
	isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn;
}

const REACT_DOM_TEST_UTILS_PACKAGE = 'react-dom/test-utils';
const FIRE_EVENT_NAME = 'fireEvent';
const CREATE_EVENT_NAME = 'createEvent';
const USER_EVENT_NAME = 'userEvent';
const RENDER_NAME = 'render';

export type DetectionOptions = {
	/**
	 * If true, force `detectTestingLibraryUtils` to skip `canReportErrors`
	 * so it doesn't opt-out rule listener.
	 *
	 * Useful when some rule apply to files other than testing ones
	 * (e.g. `consistent-data-testid`)
	 */
	skipRuleReportingCheck: boolean;
};

/**
 * Enhances a given rule `create` with helpers to detect Testing Library utils.
 */
export function detectTestingLibraryUtils<
	TMessageIds extends string,
	TOptions extends readonly unknown[],
>(
	ruleCreate: EnhancedRuleCreate<TMessageIds, TOptions>,
	{ skipRuleReportingCheck = false }: Partial<DetectionOptions> = {}
) {
	return (
		context: TestingLibraryContext<TMessageIds, TOptions>,
		optionsWithDefault: Readonly<TOptions>
	): TSESLint.RuleListener => {
		const importedTestingLibraryNodes: ImportModuleNode[] = [];
		let importedCustomModuleNode: ImportModuleNode | null = null;
		let importedUserEventLibraryNode: ImportModuleNode | null = null;
		let importedReactDomTestUtilsNode: ImportModuleNode | null = null;

		// Init options based on shared ESLint settings
		const customModuleSetting =
			context.settings['testing-library/utils-module'];
		const customRendersSetting =
			context.settings['testing-library/custom-renders'];
		const customQueriesSetting =
			context.settings['testing-library/custom-queries'];

		/**
		 * Small method to extract common checks to determine whether a node is
		 * related to Testing Library or not.
		 *
		 * To determine whether a node is a valid Testing Library util, there are
		 * two conditions to match:
		 * - it's named in a particular way (decided by given callback)
		 * - it's imported from valid Testing Library module (depends on aggressive
		 *    reporting)
		 */
		function isPotentialTestingLibraryFunction(
			node: TSESTree.Identifier | null | undefined,
			isPotentialFunctionCallback: (
				identifierNodeName: string,
				originalNodeName?: string
			) => boolean
		): boolean {
			if (!node) {
				return false;
			}

			const referenceNode = getReferenceNode(node);
			const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode);

			if (!referenceNodeIdentifier) {
				return false;
			}

			const importedUtilSpecifier = getTestingLibraryImportedUtilSpecifier(
				referenceNodeIdentifier
			);

			const originalNodeName =
				isImportSpecifier(importedUtilSpecifier) &&
				ASTUtils.isIdentifier(importedUtilSpecifier.imported) &&
				importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name
					? importedUtilSpecifier.imported.name
					: undefined;

			if (!isPotentialFunctionCallback(node.name, originalNodeName)) {
				return false;
			}

			if (isAggressiveModuleReportingEnabled()) {
				return true;
			}

			return isNodeComingFromTestingLibrary(referenceNodeIdentifier);
		}

		/**
		 * Determines whether aggressive module reporting is enabled or not.
		 *
		 * This aggressive reporting mechanism is considered as enabled when custom
		 * module is not set, so we need to assume everything matching Testing
		 * Library utils is related to Testing Library no matter from where module
		 * they are coming from. Otherwise, this aggressive reporting mechanism is
		 * opted-out in favour to report only those utils coming from Testing
		 * Library package or custom module set up on settings.
		 */
		const isAggressiveModuleReportingEnabled = () => !customModuleSetting;

		/**
		 * Determines whether aggressive render reporting is enabled or not.
		 *
		 * This aggressive reporting mechanism is considered as enabled when custom
		 * renders are not set, so we need to assume every method containing
		 * "render" is a valid Testing Library `render`. Otherwise, this aggressive
		 * reporting mechanism is opted-out in favour to report only `render` or
		 * names set up on custom renders setting.
		 */
		const isAggressiveRenderReportingEnabled = (): boolean => {
			const isSwitchedOff = customRendersSetting === SETTING_OPTION_OFF;
			const hasCustomOptions =
				Array.isArray(customRendersSetting) && customRendersSetting.length > 0;

			return !isSwitchedOff && !hasCustomOptions;
		};

		/**
		 * Determines whether Aggressive Reporting for queries is enabled or not.
		 *
		 * This Aggressive Reporting mechanism is considered as enabled when custom-queries setting is not set,
		 * so the plugin needs to report both built-in and custom queries.
		 * Otherwise, this Aggressive Reporting mechanism is opted-out in favour of reporting only built-in queries + those
		 * indicated in custom-queries setting.
		 */
		const isAggressiveQueryReportingEnabled = (): boolean => {
			const isSwitchedOff = customQueriesSetting === SETTING_OPTION_OFF;
			const hasCustomOptions =
				Array.isArray(customQueriesSetting) && customQueriesSetting.length > 0;

			return !isSwitchedOff && !hasCustomOptions;
		};

		const getCustomModule = (): string | undefined => {
			if (
				!isAggressiveModuleReportingEnabled() &&
				customModuleSetting !== SETTING_OPTION_OFF
			) {
				return customModuleSetting;
			}
			return undefined;
		};

		const getCustomRenders = (): string[] => {
			if (
				!isAggressiveRenderReportingEnabled() &&
				customRendersSetting !== SETTING_OPTION_OFF
			) {
				return customRendersSetting as string[];
			}

			return [];
		};

		const getCustomQueries = (): string[] => {
			if (
				!isAggressiveQueryReportingEnabled() &&
				customQueriesSetting !== SETTING_OPTION_OFF
			) {
				return customQueriesSetting as string[];
			}

			return [];
		};

		// Helpers for Testing Library detection.
		const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => {
			return importedTestingLibraryNodes[0];
		};

		const getAllTestingLibraryImportNodes: GetTestingLibraryImportNodesFn =
			() => {
				return importedTestingLibraryNodes;
			};

		const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => {
			return importedCustomModuleNode;
		};

		const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => {
			return getImportModuleName(importedTestingLibraryNodes[0]);
		};

		const getCustomModuleImportName: GetCustomModuleImportNameFn = () => {
			return getImportModuleName(importedCustomModuleNode);
		};

		/**
		 * Determines whether Testing Library utils are imported or not for
		 * current file being analyzed.
		 *
		 * By default, it is ALWAYS considered as imported. This is what we call
		 * "aggressive reporting" so we don't miss TL utils reexported from
		 * custom modules.
		 *
		 * However, there is a setting to customize the module where TL utils can
		 * be imported from: "testing-library/utils-module". If this setting is enabled,
		 * then this method will return `true` ONLY IF a testing-library package
		 * or custom module are imported.
		 */
		const isTestingLibraryImported: IsTestingLibraryImportedFn = (
			isStrict = false
		) => {
			const isSomeModuleImported =
				importedTestingLibraryNodes.length !== 0 || !!importedCustomModuleNode;

			return (
				(!isStrict && isAggressiveModuleReportingEnabled()) ||
				isSomeModuleImported
			);
		};

		/**
		 * Determines whether a given node is a reportable query,
		 * either a built-in or a custom one.
		 *
		 * Depending on Aggressive Query Reporting setting, custom queries will be
		 * reportable or not.
		 */
		const isQuery: IsQueryFn = (node) => {
			const hasQueryPattern = /^(get|query|find)(All)?By.+$/.test(node.name);
			if (!hasQueryPattern) {
				return false;
			}

			if (isAggressiveQueryReportingEnabled()) {
				return true;
			}

			const customQueries = getCustomQueries();
			const isBuiltInQuery = ALL_QUERIES_COMBINATIONS.includes(node.name);
			const isReportableCustomQuery = customQueries.some((pattern) =>
				new RegExp(pattern).test(node.name)
			);
			return isBuiltInQuery || isReportableCustomQuery;
		};

		/**
		 * Determines whether a given node is `get*` query variant or not.
		 */
		const isGetQueryVariant: IsGetQueryVariantFn = (node) => {
			return isQuery(node) && node.name.startsWith('get');
		};

		/**
		 * Determines whether a given node is `query*` query variant or not.
		 */
		const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => {
			return isQuery(node) && node.name.startsWith('query');
		};

		/**
		 * Determines whether a given node is `find*` query variant or not.
		 */
		const isFindQueryVariant: IsFindQueryVariantFn = (node) => {
			return isQuery(node) && node.name.startsWith('find');
		};

		/**
		 * Determines whether a given node is sync query or not.
		 */
		const isSyncQuery: IsSyncQueryFn = (node) => {
			return isGetQueryVariant(node) || isQueryQueryVariant(node);
		};

		/**
		 * Determines whether a given node is async query or not.
		 */
		const isAsyncQuery: IsAsyncQueryFn = (node) => {
			return isFindQueryVariant(node);
		};

		const isCustomQuery: IsCustomQueryFn = (node) => {
			return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name);
		};

		const isBuiltInQuery = (node: TSESTree.Identifier): boolean => {
			return isQuery(node) && ALL_QUERIES_COMBINATIONS.includes(node.name);
		};

		/**
		 * Determines whether a given node is a valid async util or not.
		 *
		 * A node will be interpreted as a valid async util based on two conditions:
		 * the name matches with some Testing Library async util, and the node is
		 * coming from Testing Library module.
		 *
		 * The latter depends on Aggressive module reporting:
		 * if enabled, then it doesn't matter from where the given node was imported
		 * from as it will be considered part of Testing Library.
		 * Otherwise, it means `custom-module` has been set up, so only those nodes
		 * coming from Testing Library will be considered as valid.
		 */
		const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => {
			return isPotentialTestingLibraryFunction(
				node,
				(identifierNodeName, originalNodeName) => {
					return (
						(validNames as string[]).includes(identifierNodeName) ||
						(!!originalNodeName &&
							(validNames as string[]).includes(originalNodeName))
					);
				}
			);
		};

		/**
		 * Determines whether a given node is fireEvent util itself or not.
		 *
		 * Not to be confused with {@link isFireEventMethod}
		 */
		const isFireEventUtil = (node: TSESTree.Identifier): boolean => {
			return isPotentialTestingLibraryFunction(
				node,
				(identifierNodeName, originalNodeName) => {
					return [identifierNodeName, originalNodeName].includes('fireEvent');
				}
			);
		};

		/**
		 * Determines whether a given node is userEvent util itself or not.
		 *
		 * Not to be confused with {@link isUserEventMethod}
		 */
		const isUserEventUtil = (node: TSESTree.Identifier): boolean => {
			const userEvent = findImportedUserEventSpecifier();
			let userEventName: string | undefined;

			if (userEvent) {
				userEventName = userEvent.name;
			} else if (isAggressiveModuleReportingEnabled()) {
				userEventName = USER_EVENT_NAME;
			}

			if (!userEventName) {
				return false;
			}

			return node.name === userEventName;
		};

		/**
		 * Determines whether a given node is fireEvent method or not
		 */
		const isFireEventMethod: IsFireEventMethodFn = (node) => {
			const fireEventUtil =
				findImportedTestingLibraryUtilSpecifier(FIRE_EVENT_NAME);
			let fireEventUtilName: string | undefined;

			if (fireEventUtil) {
				fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil)
					? fireEventUtil.name
					: fireEventUtil.local.name;
			} else if (isAggressiveModuleReportingEnabled()) {
				fireEventUtilName = FIRE_EVENT_NAME;
			}

			if (!fireEventUtilName) {
				return false;
			}

			const parentMemberExpression: TSESTree.MemberExpression | undefined =
				node.parent && isMemberExpression(node.parent)
					? node.parent
					: undefined;

			const parentCallExpression: TSESTree.CallExpression | undefined =
				node.parent && isCallExpression(node.parent) ? node.parent : undefined;

			if (!parentMemberExpression && !parentCallExpression) {
				return false;
			}

			// check fireEvent('method', node) usage
			if (parentCallExpression) {
				return [fireEventUtilName, FIRE_EVENT_NAME].includes(node.name);
			}

			// we know it's defined at this point, but TS seems to think it is not
			// so here I'm enforcing it once in order to avoid using "!" operator every time
			const definedParentMemberExpression = parentMemberExpression!;

			// check fireEvent.click() usage
			const regularCall =
				ASTUtils.isIdentifier(definedParentMemberExpression.object) &&
				isCallExpression(definedParentMemberExpression.parent) &&
				definedParentMemberExpression.object.name === fireEventUtilName &&
				node.name !== FIRE_EVENT_NAME &&
				node.name !== fireEventUtilName;

			// check testingLibraryUtils.fireEvent.click() usage
			const wildcardCall =
				isMemberExpression(definedParentMemberExpression.object) &&
				ASTUtils.isIdentifier(definedParentMemberExpression.object.object) &&
				definedParentMemberExpression.object.object.name ===
					fireEventUtilName &&
				ASTUtils.isIdentifier(definedParentMemberExpression.object.property) &&
				definedParentMemberExpression.object.property.name ===
					FIRE_EVENT_NAME &&
				node.name !== FIRE_EVENT_NAME &&
				node.name !== fireEventUtilName;

			// check testingLibraryUtils.fireEvent('click')
			const wildcardCallWithCallExpression =
				ASTUtils.isIdentifier(definedParentMemberExpression.object) &&
				definedParentMemberExpression.object.name === fireEventUtilName &&
				ASTUtils.isIdentifier(definedParentMemberExpression.property) &&
				definedParentMemberExpression.property.name === FIRE_EVENT_NAME &&
				!isMemberExpression(definedParentMemberExpression.parent) &&
				node.name === FIRE_EVENT_NAME &&
				node.name !== fireEventUtilName;

			return regularCall || wildcardCall || wildcardCallWithCallExpression;
		};

		const isUserEventMethod: IsUserEventMethodFn = (
			node,
			userEventSetupVars
		) => {
			const userEvent = findImportedUserEventSpecifier();
			let userEventName: string | undefined;

			if (userEvent) {
				userEventName = userEvent.name;
			} else if (isAggressiveModuleReportingEnabled()) {
				userEventName = USER_EVENT_NAME;
			}

			const parentMemberExpression: TSESTree.MemberExpression | undefined =
				node.parent && isMemberExpression(node.parent)
					? node.parent
					: undefined;

			if (!parentMemberExpression) {
				return false;
			}

			// make sure that given node it's not userEvent object itself
			if (
				(userEventName &&
					[userEventName, USER_EVENT_NAME].includes(node.name)) ||
				(ASTUtils.isIdentifier(parentMemberExpression.object) &&
					parentMemberExpression.object.name === node.name)
			) {
				return false;
			}

			// check userEvent.click() usage (imported identifier)
			if (
				userEventName &&
				ASTUtils.isIdentifier(parentMemberExpression.object) &&
				parentMemberExpression.object.name === userEventName
			) {
				return true;
			}

			// check user.click() usage where user is a variable from userEvent.setup()
			if (
				userEventSetupVars &&
				ASTUtils.isIdentifier(parentMemberExpression.object) &&
				userEventSetupVars.has(parentMemberExpression.object.name)
			) {
				return true;
			}

			return false;
		};

		/**
		 * Determines whether a given node is a valid render util or not.
		 *
		 * A node will be interpreted as a valid render based on two conditions:
		 * the name matches with a valid "render" option, and the node is coming
		 * from Testing Library module. This depends on:
		 *
		 * - Aggressive render reporting: if enabled, then every node name
		 * containing "render" will be assumed as Testing Library render util.
		 * Otherwise, it means `custom-modules` has been set up, so only those nodes
		 * named as "render" or some of the `custom-modules` options will be
		 * considered as Testing Library render util.
		 * - Aggressive module reporting: if enabled, then it doesn't matter from
		 * where the given node was imported from as it will be considered part of
		 * Testing Library. Otherwise, it means `custom-module` has been set up, so
		 * only those nodes coming from Testing Library will be considered as valid.
		 */
		const isRenderUtil: IsRenderUtilFn = (node) =>
			isPotentialTestingLibraryFunction(
				node,
				(identifierNodeName, originalNodeName) => {
					if (isAggressiveRenderReportingEnabled()) {
						return identifierNodeName.toLowerCase().includes(RENDER_NAME);
					}

					return [RENDER_NAME, ...getCustomRenders()].some(
						(validRenderName) =>
							validRenderName === identifierNodeName ||
							(Boolean(originalNodeName) &&
								validRenderName === originalNodeName)
					);
				}
			);

		const isCreateEventUtil: IsCreateEventUtil = (node) => {
			const isCreateEventCallback = (
				identifierNodeName: string,
				originalNodeName?: string
			) => [identifierNodeName, originalNodeName].includes(CREATE_EVENT_NAME);
			if (
				isCallExpression(node) &&
				isMemberExpression(node.callee) &&
				ASTUtils.isIdentifier(node.callee.object)
			) {
				return isPotentialTestingLibraryFunction(
					node.callee.object,
					isCreateEventCallback
				);
			}

			if (
				isCallExpression(node) &&
				isMemberExpression(node.callee) &&
				isMemberExpression(node.callee.object) &&
				ASTUtils.isIdentifier(node.callee.object.property)
			) {
				return isPotentialTestingLibraryFunction(
					node.callee.object.property,
					isCreateEventCallback
				);
			}
			const identifier = getDeepestIdentifierNode(node);
			return isPotentialTestingLibraryFunction(
				identifier,
				isCreateEventCallback
			);
		};

		const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => {
			if (!node.init) {
				return false;
			}
			const initIdentifierNode = getDeepestIdentifierNode(node.init);

			if (!initIdentifierNode) {
				return false;
			}

			return isRenderUtil(initIdentifierNode);
		};

		const isDebugUtil: IsDebugUtilFn = (
			identifierNode,
			validNames = DEBUG_UTILS
		) => {
			const isBuiltInConsole =
				isMemberExpression(identifierNode.parent) &&
				ASTUtils.isIdentifier(identifierNode.parent.object) &&
				identifierNode.parent.object.name === 'console';

			return (
				!isBuiltInConsole &&
				isPotentialTestingLibraryFunction(
					identifierNode,
					(identifierNodeName, originalNodeName) => {
						return (
							(validNames as string[]).includes(identifierNodeName) ||
							(!!originalNodeName &&
								(validNames as string[]).includes(originalNodeName))
						);
					}
				)
			);
		};

		/**
		 * Determines whether a given node is some reportable `act` util.
		 *
		 * An `act` is reportable if some of these conditions is met:
		 * - it's related to Testing Library module (this depends on Aggressive Reporting)
		 * - it's related to React DOM Test Utils
		 */
		const isActUtil = (node: TSESTree.Identifier): boolean => {
			const isTestingLibraryAct = isPotentialTestingLibraryFunction(
				node,
				(identifierNodeName, originalNodeName) => {
					return [identifierNodeName, originalNodeName]
						.filter(Boolean)
						.includes('act');
				}
			);

			const isReactDomTestUtilsAct = (() => {
				if (!importedReactDomTestUtilsNode) {
					return false;
				}
				const referenceNode = getReferenceNode(node);
				const referenceNodeIdentifier =
					getPropertyIdentifierNode(referenceNode);
				if (!referenceNodeIdentifier) {
					return false;
				}

				const importedUtilSpecifier = findImportSpecifier(
					node.name,
					importedReactDomTestUtilsNode
				);
				if (!importedUtilSpecifier) {
					return false;
				}

				const importDeclaration = (() => {
					if (isImportDeclaration(importedUtilSpecifier.parent)) {
						return importedUtilSpecifier.parent;
					}

					const variableDeclarator = findClosestVariableDeclaratorNode(
						importedUtilSpecifier
					);

					if (isCallExpression(variableDeclarator?.init)) {
						return variableDeclarator?.init;
					}

					return undefined;
				})();
				if (!importDeclaration) {
					return false;
				}

				const importDeclarationName = getImportModuleName(importDeclaration);
				if (!importDeclarationName) {
					return false;
				}

				if (importDeclarationName !== REACT_DOM_TEST_UTILS_PACKAGE) {
					return false;
				}

				return hasImportMatch(
					importedUtilSpecifier,
					referenceNodeIdentifier.name
				);
			})();

			return isTestingLibraryAct || isReactDomTestUtilsAct;
		};

		const isTestingLibraryUtil = (node: TSESTree.Identifier): boolean => {
			return (
				isAsyncUtil(node) ||
				isQuery(node) ||
				isRenderUtil(node) ||
				isFireEventMethod(node) ||
				isUserEventMethod(node) ||
				isActUtil(node) ||
				isCreateEventUtil(node)
			);
		};

		/**
		 * Determines whether a given MemberExpression node is a presence assert
		 *
		 * Presence asserts could have shape of:
		 *  - expect(element).toBeInTheDocument()
		 *  - expect(element).not.toBeNull()
		 */
		const isPresenceAssert: IsPresenceAssertFn = (node) => {
			const { matcher, isNegated } = getAssertNodeInfo(node);

			if (!matcher) {
				return false;
			}

			return isNegated
				? ABSENCE_MATCHERS.some((absenceMather) => absenceMather === matcher)
				: PRESENCE_MATCHERS.some(
						(presenceMather) => presenceMather === matcher
					);
		};

		/**
		 * Determines whether a given MemberExpression node is an absence assert
		 *
		 * Absence asserts could have shape of:
		 *  - expect(element).toBeNull()
		 *  - expect(element).not.toBeInTheDocument()
		 */
		const isAbsenceAssert: IsAbsenceAssertFn = (node) => {
			const { matcher, isNegated } = getAssertNodeInfo(node);

			if (!matcher) {
				return false;
			}

			return isNegated
				? PRESENCE_MATCHERS.some((presenceMather) => presenceMather === matcher)
				: ABSENCE_MATCHERS.some((absenceMather) => absenceMather === matcher);
		};

		const isMatchingAssert: IsMatchingAssertFn = (node, matcherName) => {
			const { matcher } = getAssertNodeInfo(node);

			if (!matcher) {
				return false;
			}

			return matcher === matcherName;
		};

		/**
		 * Finds the import util specifier related to Testing Library for a given name.
		 */
		const findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn =
			(
				specifierName
			): TSESTree.Identifier | TSESTree.ImportClause | undefined => {
				const node =
					getCustomModuleImportNode() ?? getTestingLibraryImportNode();

				if (!node) {
					return undefined;
				}

				return findImportSpecifier(specifierName, node);
			};

		const findImportedUserEventSpecifier: () => TSESTree.Identifier | null =
			() => {
				if (!importedUserEventLibraryNode) {
					return null;
				}

				if (isImportDeclaration(importedUserEventLibraryNode)) {
					const userEventIdentifier =
						importedUserEventLibraryNode.specifiers.find((specifier) =>
							isImportDefaultSpecifier(specifier)
						);

					if (userEventIdentifier) {
						return userEventIdentifier.local;
					}
				} else {
					if (
						!ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent)
					) {
						return null;
					}

					const requireNode = importedUserEventLibraryNode.parent;
					if (!ASTUtils.isIdentifier(requireNode.id)) {
						return null;
					}

					return requireNode.id;
				}

				return null;
			};

		const getTestingLibraryImportedUtilSpecifier = (
			node: TSESTree.Identifier | TSESTree.MemberExpression
		): TSESTree.Identifier | TSESTree.ImportClause | undefined => {
			const identifierName: string | undefined =
				getPropertyIdentifierNode(node)?.name;

			if (!identifierName) {
				return undefined;
			}

			return findImportedTestingLibraryUtilSpecifier(identifierName);
		};

		/**
		 * Determines if file inspected meets all conditions to be reported by rules or not.
		 */
		const canReportErrors: CanReportErrorsFn = () => {
			return skipRuleReportingCheck || isTestingLibraryImported();
		};

		/**
		 * Determines whether a node is imported from a valid Testing Library module
		 *
		 * This method will try to find any import matching the given node name,
		 * and also make sure the name is a valid match in case it's been renamed.
		 */
		const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = (
			node
		) => {
			const importNode = getTestingLibraryImportedUtilSpecifier(node);

			if (!importNode) {
				return false;
			}

			const referenceNode = getReferenceNode(node);
			const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode);
			if (!referenceNodeIdentifier) {
				return false;
			}

			const importDeclaration = (() => {
				if (isImportDeclaration(importNode.parent)) {
					return importNode.parent;
				}

				const variableDeclarator =
					findClosestVariableDeclaratorNode(importNode);

				if (isCallExpression(variableDeclarator?.init)) {
					return variableDeclarator?.init;
				}

				return undefined;
			})();

			if (!importDeclaration) {
				return false;
			}

			const importDeclarationName = getImportModuleName(importDeclaration);
			if (!importDeclarationName) {
				return false;
			}

			const identifierName: string | undefined =
				getPropertyIdentifierNode(node)?.name;

			if (!identifierName) {
				return false;
			}

			const hasImportElementMatch = hasImportMatch(importNode, identifierName);

			return (
				hasImportElementMatch &&
				isTestingLibraryModule(importDeclarationName, customModuleSetting)
			);
		};

		const helpers: DetectionHelpers = {
			getTestingLibraryImportNode,
			getAllTestingLibraryImportNodes,
			getCustomModuleImportNode,
			getTestingLibraryImportName,
			getCustomModuleImportName,
			isTestingLibraryImported,
			isTestingLibraryUtil,
			isGetQueryVariant,
			isQueryQueryVariant,
			isFindQueryVariant,
			isSyncQuery,
			isAsyncQuery,
			isQuery,
			isCustomQuery,
			isBuiltInQuery,
			isAsyncUtil,
			isFireEventUtil,
			isUserEventUtil,
			isFireEventMethod,
			isUserEventMethod,
			isRenderUtil,
			isCreateEventUtil,
			isRenderVariableDeclarator,
			isDebugUtil,
			isActUtil,
			isPresenceAssert,
			isMatchingAssert,
			isAbsenceAssert,
			canReportErrors,
			findImportedTestingLibraryUtilSpecifier,
			isNodeComingFromTestingLibrary,
		};

		// Instructions for Testing Library detection.
		const detectionInstructions: TSESLint.RuleListener = {
			/**
			 * This ImportDeclaration rule listener will check if Testing Library related
			 * modules are imported. Since imports happen first thing in a file, it's
			 * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule`
			 * since they will have corresponding value already updated when reporting other
			 * parts of the file.
			 */
			ImportDeclaration(node: TSESTree.ImportDeclaration) {
				if (typeof node.source.value !== 'string') {
					return;
				}
				// check only if testing library import not found yet so we avoid
				// to override importedTestingLibraryNodes after it's found
				if (isOfficialTestingLibraryModule(node.source.value)) {
					importedTestingLibraryNodes.push(node);
				}

				// check only if custom module import not found yet so we avoid
				// to override importedCustomModuleNode after it's found
				const customModule = getCustomModule();
				if (
					!importedCustomModuleNode &&
					isCustomTestingLibraryModule(node.source.value, customModule)
				) {
					importedCustomModuleNode = node;
				}

				// check only if user-event import not found yet so we avoid
				// to override importedUserEventLibraryNode after it's found
				if (
					!importedUserEventLibraryNode &&
					node.source.value === USER_EVENT_MODULE
				) {
					importedUserEventLibraryNode = node;
				}

				// check only if react-dom/test-utils import not found yet so we avoid
				// to override importedReactDomTestUtilsNode after it's found
				if (
					!importedUserEventLibraryNode &&
					node.source.value === REACT_DOM_TEST_UTILS_PACKAGE
				) {
					importedReactDomTestUtilsNode = node;
				}
			},

			// Check if Testing Library related modules are loaded with required.
			[`CallExpression > Identifier[name="require"]`](
				node: TSESTree.Identifier
			) {
				const callExpression = node.parent as TSESTree.CallExpression;
				const { arguments: args } = callExpression;

				if (
					args.some(
						(arg) =>
							isLiteral(arg) &&
							typeof arg.value === 'string' &&
							isOfficialTestingLibraryModule(arg.value)
					)
				) {
					importedTestingLibraryNodes.push(callExpression);
				}

				const customModule = getCustomModule();
				if (
					!importedCustomModuleNode &&
					args.some(
						(arg) =>
							isLiteral(arg) &&
							typeof arg.value === 'string' &&
							isCustomTestingLibraryModule(arg.value, customModule)
					)
				) {
					importedCustomModuleNode = callExpression;
				}

				if (
					!importedCustomModuleNode &&
					args.some(
						(arg) =>
							isLiteral(arg) &&
							typeof arg.value === 'string' &&
							arg.value === USER_EVENT_MODULE
					)
				) {
					importedUserEventLibraryNode = callExpression;
				}

				if (
					!importedReactDomTestUtilsNode &&
					args.some(
						(arg) =>
							isLiteral(arg) &&
							typeof arg.value === 'string' &&
							arg.value === REACT_DOM_TEST_UTILS_PACKAGE
					)
				) {
					importedReactDomTestUtilsNode = callExpression;
				}
			},
		};

		// update given rule to inject Testing Library detection
		const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers);
		const enhancedRuleInstructions: TSESLint.RuleListener = {};

		const allKeys = new Set(
			Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions))
		);

		// Iterate over ALL instructions keys so we can override original rule instructions
		// to prevent their execution if conditions to report errors are not met.
		allKeys.forEach((instruction) => {
			enhancedRuleInstructions[instruction] = (node) => {
				if (instruction in detectionInstructions) {
					detectionInstructions[instruction]?.(node);
				}

				if (canReportErrors() && ruleInstructions[instruction]) {
					return ruleInstructions[instruction]?.(node);
				}

				return undefined;
			};
		});

		return enhancedRuleInstructions;
	};
}
